// Simple yet flexible JSON editor plugin.
// Turns any element into a stylable interactive JSON editor.

// Copyright (c) 2011 David Durman
// Changes Copyright (C) 2013 Intelligent Artefacts Ltd

// Licensed under the MIT license (http://www.opensource.org/licenses/mit-license.php).

// Dependencies:

// * jQuery
// * JSON (use json2 library for browsers that do not support JSON natively)

// Example:

//     var myjson = { any: { json: { value: 1 } } };
//     var opt = { change: function() { /* called on every change */ } };
//     /* opt.propertyElement = '<textarea>'; */ // element of the property field, <input> is default
//     /* opt.valueElement = '<textarea>'; */  // element of the value field, <input> is default
//     $('#mydiv').jsonEditor(myjson, opt);

(function( $ ) {

    $.fn.jsonEditor = function(json, options) {
        options = options || {};

        // Make sure functions or other non-JSON data types are stripped down.
        json = parse(stringify(json));
		
        var K = function () { },
            onchange = options.change || K,
            onvalchange = options.valchange || K,
            onexpandchange = options.expandchange || K,
            constructfilter = options.constructfilter || function() { return true; },
            pathSplitChar = options.pathSplitChar || '.';

        return this.each(function() {
            JSONEditor($(this), json, onchange, onvalchange, onexpandchange, constructfilter, options.propertyElement, options.valueElement, pathSplitChar);
        });
    };

    function JSONEditor(target, json, onchange, onvalchange, onexpandchange, constructfilter, propertyElement, valueElement, pathSplitChar) {
        var opt = {
            target: target,
            onchange: onchange,
            onvalchange: onvalchange,
            onexpandchange: onexpandchange,
            constructfilter: constructfilter,
            original: json,
            propertyElement: propertyElement,
            valueElement: valueElement,
            pathSplitChar: pathSplitChar
        };
        construct(opt, json, opt.target);
        opt.target.on('blur focus', '.property, .value', function() {
            $(this).toggleClass('editing');
        });
    }

    function isObject(o) { return Object.prototype.toString.call(o) == '[object Object]'; }
    function isArray(o) { return Object.prototype.toString.call(o) == '[object Array]'; }
    function isBoolean(o) { return Object.prototype.toString.call(o) == '[object Boolean]'; }
    function isNumber(o) { return Object.prototype.toString.call(o) == '[object Number]'; }
    function isString(o) { return Object.prototype.toString.call(o) == '[object String]'; }
    var types = 'object array boolean number string null';

    // Feeds object `o` with `value` at `path`. If value argument is omitted,
    // object at `path` will be deleted from `o`.
    // Example:
    //      feed({}, 'foo.bar.baz', 10);    // returns { foo: { bar: { baz: 10 } } }
    function feed(o, path, value, pathSplitChar) {
        var del = arguments.length == 2;

        if (path.indexOf(pathSplitChar) > -1) {
            var diver = o,
                i = 0,
                parts = path.split(pathSplitChar);
            for (var len = parts.length; i < len - 1; i++) {
                diver = diver[parts[i]];
            }
            if (del) delete diver[parts[len - 1]];
            else diver[parts[len - 1]] = value;
        } else {
            if (del) delete o[path];
            else o[path] = value;
        }
        return o;
    }

    // Get a property by path from object o if it exists. If not, return defaultValue.
    // Example:
    //     def({ foo: { bar: 5 } }, 'foo.bar', 100);   // returns 5
    //     def({ foo: { bar: 5 } }, 'foo.baz', 100);   // returns 100
    function def(o, path, defaultValue, pathSplitChar) {
        path = path.split(pathSplitChar);
        var i = 0;
        while (i < path.length) {
            if ((o = o[path[i++]]) == undefined) return defaultValue;
        }
        return o;
    }

    function error(reason) { if (window.console) { console.error(reason); } }

    function parse(str) {
        var res;
        try { res = JSON.parse(str); }
        catch (e) { res = null; error('JSON parse failed.'); }
        return res;
    }

    function stringify(obj) {
        var res;
        try { res = JSON.stringify(obj); }
        catch (e) { res = 'null'; error('JSON stringify failed.'); }
        return res;
    }

    function addExpander(item, opt) {
        if (item.children('.expander').length == 0) {
            var expander =   $('<span>',  { 'class': 'expander' });
            expander.bind('click', function() {
                var key = item.children('.property').attr('title'),
                    val = parse(item.children('.value').attr('title') || 'null'),
                    path = item.data('path');

                item.toggleClass('expanded');
                opt.onexpandchange(item.hasClass('expanded'), (path ? path + opt.pathSplitChar : '') + key, val, opt.original);
            });
            item.prepend(expander);
        }
    }

    function construct(opt, json, root, path) {
        path = path || '';
        var expanded = {};
        root.find('.item').each(function () {
            if ($(this).hasClass('expanded')) {
                var path = $(this).data('path');
                var full_path = (path ? path + opt.pathSplitChar : path) + $(this).children('.property').attr('title');
                expanded[full_path] = true;
            }
        });

        doConstruct(opt, json, root, path, expanded);
    }

    function doConstruct(opt, json, root, path, expanded) {
        root.children('.item').remove();
        doConstruct2(opt, json, root, path, expanded);
    }

    function doConstruct2(opt, json, root, path, expanded) {
        root.children('.adder').remove();

        var sorted_keys = Object.keys(json).sort(function (a, b) {
            if (a == "Global") return -1;
            if (b == "Global") return 1;
            if (a == "Enable") return -1;
            if (b == "Enable") return 1;
            if (a < b) return -1;
            return 1;
        });
        var keys_len = sorted_keys.length;

        for (var i = 0; i < keys_len; i++) {
            var key = sorted_keys[i];
            if (!json.hasOwnProperty(key)) continue;
            if (!opt.constructfilter(opt, json, key)) continue;

            var displayKey = key;
            if (key.indexOf(opt.pathSplitChar) != -1)
            {
                var newKey = key.replace(/\./g, '\t');
                json[newKey] = json[key];
                delete json[key];
                key = newKey;
            }

            var item     = $('<div>',   { 'class': 'item', 'data-path': path }),
                property =   $(opt.propertyElement || '<input>', { 'class': 'property' }),
                value    =   $(opt.valueElement || '<input>', { 'class': 'value'    }),
                full_path = (path ? path + opt.pathSplitChar : path) + key;

            if (isObject(json[key]) || isArray(json[key])) {
                addExpander(item, opt);
                if (full_path in expanded) {
                    item.addClass('expanded');
                }
            }

            item.append(property).append(value);
            root.append(item);

            property.val(displayKey).attr('title', key);

            if (isBoolean(json[key]))
            {
                value[0].type = 'checkbox';
                value[0].checked = json[key];
            }

            var val = stringify(json[key]);
            value.val(val).attr('title', val);

            assignType(item, json[key]);

            property.change(propertyChanged(opt));
            value.change(valueChanged(opt));

            if (isObject(json[key]) || isArray(json[key])) {
                doConstruct(opt, json[key], item, full_path, expanded);

                var adder = $('<div>', { 'class': 'adder', 'data-path': full_path });
                adder.bind('click', function () {
                    var theParent = $(this).parent()
                    doConstruct2(opt, { "": "" }, theParent, $(this).data('path'), true);
                    theParent.append(this);
                });
                item.append(adder);
            }
        }
    }

    function updateParents(el, opt) {
        $(el).parentsUntil(opt.target).each(function() {
            var path = $(this).data('path');
            path = (path ? path + opt.pathSplitChar : path) + $(this).children('.property').val();
            var val = stringify(def(opt.original, path, null, opt.pathSplitChar));
            $(this).children('.value').val(val).attr('title', val);
        });
    }

    function propertyChanged(opt) {
        return function() {
            var path = $(this).parent().data('path'),
                val = parse($(this).next().val()),
                newKey = $(this).val(),
                oldKey = $(this).attr('title');

            $(this).attr('title', newKey);

            feed(opt.original, (path ? path + opt.pathSplitChar : '') + oldKey, opt.pathSplitChar);
            if (newKey) feed(opt.original, (path ? path + opt.pathSplitChar : '') + newKey, val, opt.pathSplitChar);

            updateParents(this, opt);

            if (!newKey) $(this).parent().remove();

            opt.onchange();
        };
    }

    function valueChanged(opt) {
        return function() {
            var key = $(this).prev().val(),
                val = parse($(this).val() || 'null'),
                item = $(this).parent(),
                path = item.data('path');

            if (this.type == 'checkbox')
                val = this.checked;

            opt.onvalchange((path ? path + opt.pathSplitChar : '') + key, val, opt.original);

            feed(opt.original, (path ? path + opt.pathSplitChar : '') + key, val, opt.pathSplitChar);
            if ((isObject(val) || isArray(val)) && !$.isEmptyObject(val)) {
                construct(opt, val, item, (path ? path + opt.pathSplitChar : '') + key);
                addExpander(item, opt);
            } else {
                item.find('.expander, .item').remove();
            }

            assignType(item, val);

            updateParents(this, opt);

            opt.onchange();
        };
    }

    function assignType(item, val) {
        var className = 'null';

        if (isObject(val)) className = 'object';
        else if (isArray(val)) className = 'array';
        else if (isBoolean(val)) className = 'boolean';
        else if (isString(val)) className = 'string';
        else if (isNumber(val)) className = 'number';

        item.removeClass(types);
        item.addClass(className);
    }

})( jQuery );
